page.tsx 71 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { AssetCard } from '@/components/ui/AssetCard';
  8. import { FolderTree } from '@/components/folders/FolderTree';
  9. import { ShareModal } from '@/components/share/ShareModal';
  10. import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
  11. import { useDropzone } from 'react-dropzone';
  12. async function safeCopy(text: string): Promise<void> {
  13. if (typeof window === 'undefined') return;
  14. if (navigator.clipboard?.writeText) {
  15. try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
  16. } else {
  17. const el = document.createElement('textarea');
  18. el.value = text;
  19. el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
  20. document.body.appendChild(el);
  21. el.focus(); el.select();
  22. try { document.execCommand('copy'); } catch { /* ignore */ }
  23. document.body.removeChild(el);
  24. }
  25. }
  26. const ROLE_COLORS: Record<string, string> = {
  27. ADMIN: 'badge-danger',
  28. EDITOR: 'badge-brand',
  29. REVIEWER:'badge-muted',
  30. VIEWER: 'badge-subtle',
  31. };
  32. const ROLE_LABELS: Record<string, string> = {
  33. ADMIN: 'Admin',
  34. EDITOR: 'Editor',
  35. REVIEWER:'Reviewer',
  36. VIEWER: 'Viewer',
  37. };
  38. function formatGroupDate(d: Date): string {
  39. const now = new Date();
  40. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  41. const yesterday = new Date(today.getTime() - 86400000);
  42. const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  43. if (videoDay.getTime() === today.getTime()) return 'Today';
  44. if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
  45. return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
  46. }
  47. function groupByDay(assets: Asset[]): [string, Asset[]][] {
  48. const sorted = [...assets].sort(
  49. (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  50. );
  51. const groups: Record<string, Asset[]> = {};
  52. for (const a of sorted) {
  53. const d = new Date(a.createdAt);
  54. const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
  55. if (!groups[day]) groups[day] = [];
  56. groups[day].push(a);
  57. }
  58. return Object.entries(groups);
  59. }
  60. /** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
  61. function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
  62. const ids = new Set<string>();
  63. if (targetId === null) return ids; // "All Videos" — no filter
  64. function findTarget(f: FolderNode): FolderNode | null {
  65. if (f.id === targetId) return f;
  66. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  67. return null;
  68. }
  69. for (const f of folders) {
  70. const target = findTarget(f);
  71. if (target) { for (const id of target.assetIds) ids.add(id); break; }
  72. }
  73. return ids;
  74. }
  75. /** Get direct subfolders of a folder */
  76. function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
  77. if (targetId === null) return folders; // root: show top-level folders
  78. function findTarget(f: FolderNode): FolderNode | null {
  79. if (f.id === targetId) return f;
  80. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  81. return null;
  82. }
  83. for (const f of folders) {
  84. const target = findTarget(f);
  85. if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
  86. }
  87. return [];
  88. }
  89. /** Build a map of assetId -> single deepest folder name */
  90. function buildAssetFolders(allFolders: FolderNode[]): Map<string, string> {
  91. const map = new Map<string, string>();
  92. const depthMap = new Map<string, number>();
  93. function search(f: FolderNode, depth: number): void {
  94. for (const id of f.assetIds) {
  95. const existingDepth = depthMap.get(id) ?? -1;
  96. if (depth > existingDepth) {
  97. map.set(id, f.name);
  98. depthMap.set(id, depth);
  99. }
  100. }
  101. for (const child of f.children) search(child, depth + 1);
  102. }
  103. for (const f of allFolders) search(f, 0);
  104. return map;
  105. }
  106. /** Get the folder name an asset belongs to (deepest only) */
  107. function getAssetFolderNames(assetFolders: Map<string, string>, assetId: string): string[] {
  108. const name = assetFolders.get(assetId);
  109. return name ? [name] : [];
  110. }
  111. /** Returns a breadcrumb path of folder names for the selected folder */
  112. function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
  113. if (targetId === null) return [];
  114. const path: string[] = [];
  115. function search(f: FolderNode, trail: string[]): boolean {
  116. if (f.id === targetId) { path.push(...trail, f.name); return true; }
  117. for (const child of f.children) {
  118. if (search(child, [...trail, f.name])) return true;
  119. }
  120. return false;
  121. }
  122. for (const f of folders) if (search(f, [])) break;
  123. return path;
  124. }
  125. export default function ProjectDetailPage() {
  126. const params = useParams();
  127. const projectId = params.projectId as string;
  128. const { user, token } = useAuth();
  129. const router = useRouter();
  130. const [project, setProject] = useState<Project | null>(null);
  131. const [members, setMembers] = useState<any[]>([]);
  132. const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
  133. const [assets, setAssets] = useState<Asset[]>([]);
  134. const [folders, setFolders] = useState<FolderNode[]>([]);
  135. const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
  136. const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
  137. const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
  138. const [loading, setLoading] = useState(true);
  139. const [uploading, setUploading] = useState(false);
  140. const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
  141. const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
  142. // Invite form state (single shared form)
  143. const [inviteEmail, setInviteEmail] = useState('');
  144. const [inviteRole, setInviteRole] = useState('REVIEWER');
  145. const [inviting, setInviting] = useState(false);
  146. const [inviteError, setInviteError] = useState('');
  147. const [inviteSuccess, setInviteSuccess] = useState('');
  148. const [createdLink, setCreatedLink] = useState('');
  149. // Edit member role
  150. const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
  151. const [editingRole, setEditingRole] = useState('');
  152. const [updatingRole, setUpdatingRole] = useState(false);
  153. // Remove member
  154. const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
  155. const [removing, setRemoving] = useState(false);
  156. // Revoke invite
  157. const [revokingId, setRevokingId] = useState<string | null>(null);
  158. // Copy link
  159. const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
  160. const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
  161. const canManage = members.some(m =>
  162. m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
  163. );
  164. const isOwner = project?.ownerId === user?.id;
  165. const isAdmin = members.some(m =>
  166. m.user.id === user?.id && m.role === 'ADMIN'
  167. );
  168. // ── Folder data derived from state ──────────────────────────────────────────
  169. // For file mode: only assets directly in the selected folder
  170. const folderAssetIds = assets.length > 0
  171. ? collectAssetIds(folders, selectedFolderId)
  172. : new Set<string>();
  173. // For timeline mode: assets in selected folder AND all its subfolders
  174. const timelineAssetIds = (() => {
  175. const ids = new Set<string>();
  176. if (selectedFolderId === null) return ids;
  177. function findTarget(f: FolderNode): FolderNode | null {
  178. if (f.id === selectedFolderId) return f;
  179. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  180. return null;
  181. }
  182. function collectAll(f: FolderNode): void {
  183. for (const id of f.assetIds) ids.add(id);
  184. for (const c of f.children) collectAll(c);
  185. }
  186. for (const f of folders) {
  187. const target = findTarget(f);
  188. if (target) { collectAll(target); break; }
  189. }
  190. return ids;
  191. })();
  192. const filteredAssets = selectedFolderId === null
  193. ? assets
  194. : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
  195. // Timeline uses all assets in the selected folder AND its subfolders
  196. const timelineAssets = selectedFolderId === null
  197. ? assets
  198. : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
  199. const subfolders = getSubfolders(folders, selectedFolderId);
  200. const breadcrumb = getBreadcrumb(folders, selectedFolderId);
  201. const assetFolders = buildAssetFolders(allFolders);
  202. // ── Delete project ──────────────────────────────────────────────────────────
  203. const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
  204. const [deletingProject, setDeletingProject] = useState(false);
  205. const handleDeleteProject = async () => {
  206. if (!token) return;
  207. setDeletingProject(true);
  208. try {
  209. await projectsApi.delete(token, projectId);
  210. router.push('/projects');
  211. } catch (err) {
  212. alert(err instanceof Error ? err.message : 'Failed to delete project');
  213. } finally {
  214. setDeletingProject(false);
  215. setConfirmDeleteProject(false);
  216. }
  217. };
  218. const loadFolders = useCallback(async () => {
  219. if (!token) return;
  220. try {
  221. const data = await foldersApi.list(token, projectId);
  222. setFolders(data.folders);
  223. setAllFolders(data.allFolders);
  224. } catch (e) {
  225. console.error('Failed to load folders:', e);
  226. }
  227. }, [token, projectId]);
  228. const loadAll = useCallback(async () => {
  229. if (!token) return;
  230. try {
  231. const [{ project: p }, { assets: a }] = await Promise.all([
  232. projectsApi.get(token, projectId),
  233. assetsApi.list(token, projectId),
  234. ]);
  235. setProject(p);
  236. setMembers(p.members ?? []);
  237. setAssets(a);
  238. if (canManage) {
  239. const { invitations } = await invitationsApi.list(token, projectId);
  240. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  241. }
  242. } catch {
  243. router.push('/projects');
  244. } finally {
  245. setLoading(false);
  246. }
  247. }, [token, projectId, router, canManage]);
  248. useEffect(() => { loadAll(); }, [loadAll]);
  249. useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
  250. // ── Invite member ──────────────────────────────────────────────────────────
  251. const handleInvite = async (e: React.FormEvent) => {
  252. e.preventDefault();
  253. if (!token || !inviteEmail.trim()) return;
  254. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
  255. setInviteError('Invalid email address');
  256. return;
  257. }
  258. setInviting(true);
  259. setInviteError('');
  260. setInviteSuccess('');
  261. setCreatedLink('');
  262. try {
  263. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  264. const { invitations } = await invitationsApi.list(token, projectId);
  265. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  266. setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
  267. setInviteEmail('');
  268. setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
  269. setTimeout(() => setInviteSuccess(''), 3000);
  270. } catch (err) {
  271. setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
  272. } finally {
  273. setInviting(false);
  274. }
  275. };
  276. // ── Create & copy link ─────────────────────────────────────────────────────
  277. const handleCreateLink = async () => {
  278. if (!token || !inviteEmail.trim()) return;
  279. setInviting(true);
  280. setInviteError('');
  281. setInviteSuccess('');
  282. setCreatedLink('');
  283. try {
  284. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  285. const { invitations } = await invitationsApi.list(token, projectId);
  286. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  287. await safeCopy(inviteUrl);
  288. setCreatedLink(inviteUrl);
  289. setInviteEmail('');
  290. } catch (err: any) {
  291. const msg = err instanceof Error ? err.message : String(err);
  292. if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
  293. setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
  294. } else {
  295. setInviteError(msg || 'Failed to create invitation link');
  296. }
  297. } finally {
  298. setInviting(false);
  299. }
  300. };
  301. // ── Change role ────────────────────────────────────────────────────────────
  302. const handleChangeRole = async (memberId: string) => {
  303. if (!token || !editingRole) return;
  304. setUpdatingRole(true);
  305. try {
  306. await projectsApi.updateMember(token, projectId, memberId, editingRole);
  307. setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
  308. setEditingRoleId(null);
  309. } catch (err) {
  310. alert(err instanceof Error ? err.message : 'Failed to update role');
  311. } finally {
  312. setUpdatingRole(false);
  313. }
  314. };
  315. // ── Remove member ─────────────────────────────────────────────────────────
  316. const handleRemoveMember = async () => {
  317. if (!token || !confirmRemove) return;
  318. setRemoving(true);
  319. try {
  320. await projectsApi.removeMember(token, projectId, confirmRemove.id);
  321. setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
  322. setConfirmRemove(null);
  323. } catch (err) {
  324. alert(err instanceof Error ? err.message : 'Failed to remove member');
  325. } finally {
  326. setRemoving(false);
  327. }
  328. };
  329. // ── Revoke invite ──────────────────────────────────────────────────────────
  330. const handleRevoke = async (invitationId: string) => {
  331. if (!token) return;
  332. setRevokingId(invitationId);
  333. try {
  334. await invitationsApi.revoke(token, invitationId);
  335. setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
  336. } catch (err) {
  337. alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
  338. } finally {
  339. setRevokingId(null);
  340. }
  341. };
  342. // ── Copy invite link ──────────────────────────────────────────────────────
  343. const handleCopyLink = async (invite: Invitation) => {
  344. const base = window.location.origin;
  345. const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
  346. await safeCopy(url);
  347. setCopiedInviteId(invite.id);
  348. setTimeout(() => setCopiedInviteId(null), 2000);
  349. };
  350. // ── Upload ─────────────────────────────────────────────────────────────────
  351. const handleDrop = async (acceptedFiles: File[]) => {
  352. if (!token || acceptedFiles.length === 0) return;
  353. setUploading(true);
  354. for (const file of acceptedFiles) {
  355. const formData = new FormData();
  356. formData.append('video', file);
  357. formData.append('projectId', projectId);
  358. formData.append('title', file.name.replace(/\.[^.]+$/, ''));
  359. try {
  360. const result = await assetsApi.upload(token, formData) as { asset: Asset };
  361. setAssets(prev => [result.asset, ...prev]);
  362. } catch (err) {
  363. console.error('Upload failed:', err);
  364. alert(`Upload failed: ${file.name}`);
  365. }
  366. }
  367. setUploading(false);
  368. };
  369. const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
  370. onDrop: handleDrop,
  371. accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
  372. multiple: true,
  373. disabled: uploading,
  374. });
  375. // Poll for assets that are still processing
  376. const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
  377. // ── Delete asset ─────────────────────────────────────────────────────────
  378. const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
  379. const [deletingId, setDeletingId] = useState<string | null>(null);
  380. const handleDeleteAsset = (id: string, title: string) => {
  381. setConfirmDelete({ id, title });
  382. };
  383. // ── Remove asset from a folder ──────────────────────────────────────────
  384. const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
  385. if (!token) return;
  386. // Find the folder by name within the project
  387. const folder = allFolders.find(f => f.name === folderName);
  388. if (!folder) return;
  389. try {
  390. await foldersApi.removeAsset(token, folder.id, assetId);
  391. // Refresh folder data so asset disappears from the folder
  392. loadFolders();
  393. } catch (err) {
  394. console.error('Failed to remove from folder:', err);
  395. }
  396. }, [token, allFolders, loadFolders]);
  397. const confirmDeleteAsset = async () => {
  398. if (!token || !confirmDelete) return;
  399. setDeletingId(confirmDelete.id);
  400. try {
  401. await assetsApi.delete(token, confirmDelete.id);
  402. setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
  403. setConfirmDelete(null);
  404. } catch (err) {
  405. alert(err instanceof Error ? err.message : 'Failed to delete video');
  406. } finally {
  407. setDeletingId(null);
  408. }
  409. };
  410. useEffect(() => {
  411. const processingAssets = assets.filter(a =>
  412. ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
  413. );
  414. if (processingAssets.length === 0) {
  415. if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
  416. return;
  417. }
  418. if (pollingRef.current) return;
  419. pollingRef.current = setInterval(async () => {
  420. if (!token) return;
  421. try {
  422. const { assets: updated } = await assetsApi.list(token, projectId);
  423. setAssets(updated);
  424. } catch {}
  425. }, 3000);
  426. return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
  427. }, [token, projectId, assets]);
  428. if (loading) {
  429. return (
  430. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  431. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  432. <div className="w-5 h-5 rounded-full animate-spin"
  433. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  434. <span className="text-sm">Loading…</span>
  435. </div>
  436. </div>
  437. );
  438. }
  439. return (
  440. <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
  441. {/* Full-page upload overlay when dragging files */}
  442. {isUploadDragActive && (
  443. <div {...getUploadRootProps()} className="upload-drop-overlay">
  444. <input {...getUploadInputProps()} />
  445. <div className="text-center">
  446. <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  447. style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
  448. <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  449. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  450. </svg>
  451. </div>
  452. <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
  453. <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
  454. </div>
  455. </div>
  456. )}
  457. {/* Header */}
  458. <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
  459. style={{
  460. background: 'rgba(10,11,20,0.80)',
  461. backdropFilter: 'blur(12px)',
  462. borderBottom: '1px solid rgba(255,255,255,0.06)',
  463. }}>
  464. <button
  465. onClick={() => router.push('/projects')}
  466. className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
  467. style={{ color: 'var(--text-muted)' }}
  468. >
  469. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  470. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  471. </svg>
  472. <span className="hidden sm:inline">Projects</span>
  473. </button>
  474. <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
  475. <div className="flex-1 min-w-0">
  476. <div className="flex items-center gap-2">
  477. <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
  478. {project?.name}
  479. </h1>
  480. {canManage && (
  481. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  482. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  483. {isAdmin ? 'Owner' : 'Editor'}
  484. </span>
  485. )}
  486. {!canManage && !isAdmin && (
  487. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  488. style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
  489. {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
  490. </span>
  491. )}
  492. </div>
  493. {project?.description && (
  494. <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
  495. {project.description}
  496. </p>
  497. )}
  498. </div>
  499. {/* Upload button — compact, in header */}
  500. {canManage && (
  501. <button
  502. {...getUploadRootProps()}
  503. className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
  504. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
  505. title="Upload video"
  506. >
  507. <input {...getUploadInputProps()} />
  508. {uploading ? (
  509. <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
  510. ) : (
  511. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  512. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  513. </svg>
  514. )}
  515. <span className="hidden sm:inline">Upload</span>
  516. </button>
  517. )}
  518. {/* Tabs */}
  519. <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
  520. style={{ background: 'rgba(255,255,255,0.04)' }}>
  521. {[
  522. { tab: 'videos', label: 'Videos', count: assets.length },
  523. { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
  524. { tab: 'members', label: 'Members', count: members.length },
  525. ].map(({ tab, label, count }) => (
  526. <button key={tab}
  527. onClick={() => setActiveTab(tab as any)}
  528. className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
  529. style={{
  530. background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
  531. color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
  532. }}
  533. title={label}
  534. >
  535. {tab === 'videos' && (
  536. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  537. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  538. </svg>
  539. )}
  540. {tab === 'transcode' && (
  541. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  542. <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
  543. </svg>
  544. )}
  545. {tab === 'members' && (
  546. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  547. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
  548. </svg>
  549. )}
  550. <span className="hidden sm:inline">{label}</span>
  551. <span className="text-[10px] px-1 py-0.5 rounded-full"
  552. style={{
  553. background: tab === 'transcode'
  554. ? 'rgba(167,139,250,0.25)'
  555. : 'rgba(255,255,255,0.06)',
  556. color: tab === 'transcode' ? '#A78BFA' : 'inherit',
  557. }}>
  558. {count}
  559. </span>
  560. </button>
  561. ))}
  562. </div>
  563. {/* Delete project — owner only */}
  564. {isOwner && (
  565. <button
  566. onClick={() => setConfirmDeleteProject(true)}
  567. className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
  568. style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
  569. title="Delete project"
  570. >
  571. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  572. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  573. </svg>
  574. </button>
  575. )}
  576. </header>
  577. <div className="px-4 md:px-8 py-4 md:py-6">
  578. {/* ── Videos Tab ───────────────────────────────────────────────────── */}
  579. {activeTab === 'videos' && (
  580. <>
  581. {/* File/Timeline mode toggle + breadcrumb bar */}
  582. {activeTab === 'videos' && (
  583. <div className="flex items-center gap-3 mb-5 flex-wrap">
  584. {/* Breadcrumb */}
  585. <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
  586. <span className="truncate">{project?.name}</span>
  587. {breadcrumb.map((name, i) => (
  588. <span key={i} className="flex items-center gap-1 shrink-0">
  589. <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  590. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  591. </svg>
  592. <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
  593. </span>
  594. ))}
  595. </nav>
  596. <div className="flex-1" />
  597. {/* Asset count */}
  598. <span className="text-xs px-2 py-1 rounded-full shrink-0"
  599. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  600. {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
  601. </span>
  602. {/* Mode toggle */}
  603. <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
  604. style={{ background: 'rgba(255,255,255,0.05)' }}>
  605. {[
  606. { mode: 'file' as const, label: 'File', icon: (
  607. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  608. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  609. </svg>
  610. )},
  611. { mode: 'timeline' as const, label: 'Timeline', icon: (
  612. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  613. <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
  614. </svg>
  615. )},
  616. ].map(({ mode, label, icon }) => (
  617. <button key={mode}
  618. onClick={() => setViewMode(mode)}
  619. className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
  620. style={{
  621. background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
  622. color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
  623. }}
  624. >
  625. {icon}
  626. <span className="hidden sm:inline">{label}</span>
  627. </button>
  628. ))}
  629. </div>
  630. </div>
  631. )}
  632. <div className="flex gap-5">
  633. {/* Left panel: FolderTree (both file and timeline modes) */}
  634. <aside className="w-52 shrink-0 hidden md:block">
  635. <FolderTree
  636. folders={folders}
  637. allFolders={allFolders}
  638. selectedFolderId={selectedFolderId}
  639. onSelectFolder={setSelectedFolderId}
  640. canManage={canManage}
  641. token={token ?? ''}
  642. projectId={projectId}
  643. onRefresh={loadFolders}
  644. totalAssetCount={assets.length}
  645. />
  646. </aside>
  647. {/* Main content */}
  648. <div className="flex-1 min-w-0">
  649. {/* Upload zone for non-managers */}
  650. {!canManage && (
  651. <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
  652. style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
  653. <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
  654. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
  655. <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  656. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  657. </svg>
  658. </div>
  659. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  660. Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
  661. </p>
  662. </div>
  663. )}
  664. {/* File mode content */}
  665. {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
  666. <div className="text-center py-16 rounded-2xl animate-fade-in"
  667. style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
  668. <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  669. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  670. <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
  671. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  672. </svg>
  673. </div>
  674. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
  675. {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
  676. </p>
  677. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  678. {selectedFolderId
  679. ? 'Drag videos here or move them from other folders'
  680. : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
  681. </p>
  682. </div>
  683. ) : viewMode === 'file' ? (
  684. // File mode: subfolders + videos
  685. <div>
  686. {/* Subfolders */}
  687. {subfolders.length > 0 && (
  688. <div className="mb-6">
  689. <div className="flex items-center gap-3 mb-3">
  690. <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
  691. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
  692. </div>
  693. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
  694. {subfolders.map(folder => (
  695. <button
  696. key={folder.id}
  697. onClick={() => setSelectedFolderId(folder.id)}
  698. className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
  699. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
  700. >
  701. <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  702. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9A2.25 2.25 0 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  703. </svg>
  704. <div className="flex-1 min-w-0">
  705. <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
  706. {folder.assetCount > 0 && (
  707. <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
  708. )}
  709. </div>
  710. <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  711. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  712. </svg>
  713. </button>
  714. ))}
  715. </div>
  716. </div>
  717. )}
  718. {/* Videos in this folder */}
  719. {filteredAssets.length > 0 && (
  720. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
  721. {filteredAssets.map((asset, i) => (
  722. <AssetCard
  723. key={asset.id}
  724. asset={asset}
  725. canManage={canManage}
  726. showHour={false}
  727. onPlay={() => router.push(`/review/${asset.id}`)}
  728. onDelete={() => handleDeleteAsset(asset.id, asset.title)}
  729. onCancel={async (id) => {
  730. if (!token) return;
  731. try {
  732. await assetsApi.cancelTranscode(token, id);
  733. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  734. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  735. }}
  736. onPause={async (id) => {
  737. if (!token) return;
  738. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  739. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  740. }}
  741. onResume={async (id) => {
  742. if (!token) return;
  743. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  744. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  745. }}
  746. animationDelay={i * 40}
  747. folderNames={getAssetFolderNames(assetFolders, asset.id)}
  748. onShare={setSharingAssetId}
  749. isShared={!!asset.isShared}
  750. onRemoveFromFolder={handleRemoveFromFolder}
  751. />
  752. ))}
  753. </div>
  754. )}
  755. </div>
  756. ) : (
  757. // Timeline mode: grouped by date
  758. <div className="space-y-8">
  759. {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
  760. const groupDate = new Date(dayKey);
  761. const showHour = dayAssets.length > 1;
  762. return (
  763. <div key={dayKey}>
  764. <div className="flex items-center gap-3 mb-4">
  765. <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
  766. {formatGroupDate(groupDate)}
  767. </span>
  768. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
  769. <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
  770. {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
  771. </span>
  772. </div>
  773. <div className="space-y-3">
  774. {dayAssets.map((asset, i) => {
  775. const createdAt = new Date(asset.createdAt);
  776. return (
  777. <div key={asset.id}
  778. className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
  779. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
  780. onClick={() => router.push(`/review/${asset.id}`)}
  781. draggable={canManage}
  782. onDragStart={canManage ? (e) => {
  783. e.dataTransfer.setData('assetId', asset.id);
  784. e.dataTransfer.setData('text/plain', asset.title);
  785. e.dataTransfer.effectAllowed = 'move';
  786. if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
  787. const ghost = document.createElement('div');
  788. ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
  789. const img = document.createElement('img');
  790. img.src = `/uploads/${asset.thumbnail}`;
  791. img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
  792. const label = document.createElement('span');
  793. label.textContent = asset.title;
  794. label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
  795. ghost.appendChild(img);
  796. ghost.appendChild(label);
  797. document.body.appendChild(ghost);
  798. e.dataTransfer.setDragImage(ghost, 30, 28);
  799. setTimeout(() => document.body.removeChild(ghost), 0);
  800. }
  801. } : undefined}
  802. >
  803. {/* Thumbnail */}
  804. <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
  805. style={{ background: '#080810' }}>
  806. {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
  807. <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
  808. ) : (
  809. <div className="w-full h-full flex items-center justify-center">
  810. <svg className="w-6 h-6" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
  811. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  812. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  813. </svg>
  814. </div>
  815. )}
  816. </div>
  817. {/* Info */}
  818. <div className="flex-1 min-w-0">
  819. <div className="flex items-start justify-between gap-2 mb-1">
  820. <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
  821. {asset.duration && (
  822. <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
  823. style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
  824. {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
  825. </span>
  826. )}
  827. </div>
  828. {/* Folder tags */}
  829. {(() => {
  830. const tags = getAssetFolderNames(assetFolders, asset.id);
  831. return tags.length > 0 ? (
  832. <div className="flex flex-wrap gap-1 mb-1">
  833. {tags.map((name, i) => (
  834. <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
  835. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  836. {name}
  837. </span>
  838. ))}
  839. </div>
  840. ) : null;
  841. })()}
  842. <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  843. <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
  844. <span>·</span>
  845. <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
  846. {showHour
  847. ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
  848. : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  849. </span>
  850. <span>·</span>
  851. <span>{(asset as any)._count?.comments ?? 0} comments</span>
  852. </div>
  853. </div>
  854. {/* Play button */}
  855. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
  856. style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
  857. <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
  858. <path d="M8 5v14l11-7z" />
  859. </svg>
  860. </div>
  861. </div>
  862. );
  863. })}
  864. </div>
  865. </div>
  866. );
  867. })}
  868. </div>
  869. )}
  870. </div>
  871. </div>
  872. </>
  873. )}
  874. {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
  875. {activeTab === 'transcode' && (
  876. <div className="animate-fade-in">
  877. <TranscodeTasksPanel
  878. assets={assets}
  879. token={token}
  880. canManage={canManage}
  881. onDelete={handleDeleteAsset}
  882. onCancel={async (id) => {
  883. if (!token) return;
  884. try {
  885. await assetsApi.cancelTranscode(token, id);
  886. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  887. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  888. }}
  889. onPause={async (id) => {
  890. if (!token) return;
  891. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  892. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  893. }}
  894. onResume={async (id) => {
  895. if (!token) return;
  896. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  897. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  898. }}
  899. onReprocess={async (id) => {
  900. if (!token) return;
  901. try {
  902. await assetsApi.cancelTranscode(token, id);
  903. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  904. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reprocess transcode'); }
  905. }}
  906. />
  907. </div>
  908. )}
  909. {/* ── Members Tab ─────────────────────────────────────────────────── */}
  910. {activeTab === 'members' && (
  911. <div className="max-w-3xl animate-fade-in">
  912. {/* Invite form */}
  913. {canManage && (
  914. <div className="card p-5 mb-6">
  915. <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
  916. Invite someone
  917. </h2>
  918. <div className="space-y-3">
  919. <form
  920. onSubmit={e => { e.preventDefault(); handleInvite(e); }}
  921. className="flex items-end gap-3 flex-wrap"
  922. >
  923. <div className="flex-1 min-w-[180px]">
  924. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
  925. Email address
  926. </label>
  927. <input
  928. type="email"
  929. className="input"
  930. value={inviteEmail}
  931. onChange={e => setInviteEmail(e.target.value)}
  932. placeholder="colleague@company.com"
  933. />
  934. </div>
  935. <div className="w-36">
  936. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
  937. <select
  938. className="input"
  939. value={inviteRole}
  940. onChange={e => setInviteRole(e.target.value)}
  941. >
  942. {Object.entries(ROLE_LABELS).map(([value, label]) => (
  943. <option key={value} value={value}>{label}</option>
  944. ))}
  945. </select>
  946. </div>
  947. <button
  948. type="button"
  949. disabled={inviting || !inviteEmail.trim()}
  950. onClick={handleCreateLink}
  951. className="btn btn-secondary btn-md"
  952. title="Create invite link and copy to clipboard"
  953. >
  954. {inviting ? 'Creating…' : (
  955. <span className="flex items-center gap-1.5">
  956. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  957. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  958. </svg>
  959. Copy Link
  960. </span>
  961. )}
  962. </button>
  963. <button
  964. type="submit"
  965. disabled={inviting || !inviteEmail.trim()}
  966. className="btn btn-primary btn-md"
  967. title="Send invite"
  968. >
  969. {inviting ? 'Sending…' : 'Send Invite'}
  970. </button>
  971. </form>
  972. {createdLink && (
  973. <div className="rounded-lg p-3 animate-scale-in"
  974. style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
  975. <div className="flex items-center gap-2 mb-1.5">
  976. <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  977. <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  978. </svg>
  979. <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied!</span>
  980. </div>
  981. <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
  982. {createdLink}
  983. </p>
  984. </div>
  985. )}
  986. {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
  987. {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
  988. </div>
  989. </div>
  990. )}
  991. {/* Members list */}
  992. <div className="card overflow-hidden mb-6">
  993. <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  994. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  995. Members ({members.length})
  996. </h2>
  997. </div>
  998. {members.length === 0 ? (
  999. <div className="p-8 text-center">
  1000. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
  1001. </div>
  1002. ) : (
  1003. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1004. {members.map(m => {
  1005. const isMe = m.user.id === user?.id;
  1006. const canEdit = isAdmin && !isMe;
  1007. return (
  1008. <div key={m.id}
  1009. className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
  1010. <Avatar name={m.user.name} src={m.user.avatarUrl} size="md" />
  1011. <div className="flex-1 min-w-0">
  1012. <div className="flex items-center gap-2">
  1013. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
  1014. {m.user.name}
  1015. {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
  1016. </span>
  1017. </div>
  1018. <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
  1019. </div>
  1020. <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
  1021. {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
  1022. </span>
  1023. {editingRoleId === m.id ? (
  1024. <div className="flex items-center gap-2 shrink-0">
  1025. <select
  1026. className="input text-xs py-1.5"
  1027. value={editingRole}
  1028. onChange={e => setEditingRole(e.target.value)}
  1029. autoFocus
  1030. >
  1031. {Object.entries(ROLE_LABELS).map(([v, l]) => (
  1032. <option key={v} value={v}>{l}</option>
  1033. ))}
  1034. </select>
  1035. <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
  1036. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1037. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1038. </svg>
  1039. </button>
  1040. <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
  1041. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1042. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1043. </svg>
  1044. </button>
  1045. </div>
  1046. ) : (
  1047. <div className="flex items-center gap-2 shrink-0">
  1048. <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
  1049. {ROLE_LABELS[m.role] ?? m.role}
  1050. </span>
  1051. {canEdit && (
  1052. <button
  1053. onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
  1054. className="btn btn-secondary btn-sm"
  1055. title="Change role"
  1056. >
  1057. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1058. <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
  1059. </svg>
  1060. </button>
  1061. )}
  1062. {isAdmin && !isMe && (
  1063. <button
  1064. onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
  1065. className="btn btn-danger btn-sm"
  1066. title="Remove from project"
  1067. >
  1068. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1069. <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
  1070. </svg>
  1071. </button>
  1072. )}
  1073. </div>
  1074. )}
  1075. </div>
  1076. );
  1077. })}
  1078. </div>
  1079. )}
  1080. </div>
  1081. {/* Pending invitations */}
  1082. {canManage && (
  1083. <div className="card overflow-hidden">
  1084. <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  1085. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  1086. Pending invitations
  1087. </h2>
  1088. <span className="text-xs px-2 py-0.5 rounded-full"
  1089. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  1090. {pendingInvites.length}
  1091. </span>
  1092. </div>
  1093. {pendingInvites.length === 0 ? (
  1094. <div className="p-8 text-center">
  1095. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
  1096. </div>
  1097. ) : (
  1098. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1099. {pendingInvites.map(inv => (
  1100. <div key={inv.id}
  1101. className="flex items-center gap-4 px-5 py-4">
  1102. <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
  1103. style={{ background: 'rgba(99,102,241,0.08)' }}>
  1104. <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  1105. <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
  1106. </svg>
  1107. </div>
  1108. <div className="flex-1 min-w-0">
  1109. <div className="flex items-center gap-2">
  1110. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
  1111. <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
  1112. {ROLE_LABELS[inv.role] ?? inv.role}
  1113. </span>
  1114. </div>
  1115. <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
  1116. <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
  1117. <span>·</span>
  1118. <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
  1119. </div>
  1120. </div>
  1121. <div className="flex items-center gap-1.5 shrink-0">
  1122. <button
  1123. onClick={() => handleCopyLink(inv)}
  1124. className="btn btn-secondary btn-sm"
  1125. title="Copy invite link"
  1126. >
  1127. {copiedInviteId === inv.id ? (
  1128. <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1129. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1130. </svg>
  1131. ) : (
  1132. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1133. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  1134. </svg>
  1135. )}
  1136. </button>
  1137. <button
  1138. onClick={() => handleRevoke(inv.id)}
  1139. disabled={revokingId === inv.id}
  1140. className="btn btn-danger btn-sm"
  1141. title="Revoke invitation"
  1142. >
  1143. {revokingId === inv.id ? '…' : (
  1144. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1145. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1146. </svg>
  1147. )}
  1148. </button>
  1149. </div>
  1150. </div>
  1151. ))}
  1152. </div>
  1153. )}
  1154. {pendingInvites.length > 0 && (
  1155. <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1156. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1157. Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
  1158. </p>
  1159. </div>
  1160. )}
  1161. </div>
  1162. )}
  1163. </div>
  1164. )}
  1165. </div>
  1166. {/* Share modal */}
  1167. {sharingAssetId && (
  1168. <ShareModal
  1169. assetId={sharingAssetId}
  1170. onClose={() => setSharingAssetId(null)}
  1171. />
  1172. )}
  1173. {/* Delete asset confirm modal */}
  1174. {confirmDelete && (
  1175. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1176. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1177. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1178. <div className="flex items-center gap-3 mb-4">
  1179. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1180. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1181. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1182. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1183. </svg>
  1184. </div>
  1185. <div>
  1186. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
  1187. <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
  1188. "{confirmDelete.title}"
  1189. </p>
  1190. </div>
  1191. </div>
  1192. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1193. This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
  1194. </p>
  1195. <div className="flex gap-3 justify-end">
  1196. <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
  1197. <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
  1198. {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
  1199. </button>
  1200. </div>
  1201. </div>
  1202. </div>
  1203. )}
  1204. {/* Remove member confirm modal */}
  1205. {confirmRemove && (
  1206. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1207. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1208. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1209. <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
  1210. Remove {confirmRemove.name}?
  1211. </h3>
  1212. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1213. They'll lose access to this project and all its videos. They can rejoin if invited again.
  1214. </p>
  1215. <div className="flex gap-3 justify-end">
  1216. <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
  1217. <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
  1218. {removing ? 'Removing…' : 'Remove'}
  1219. </button>
  1220. </div>
  1221. </div>
  1222. </div>
  1223. )}
  1224. {/* Delete project confirm modal */}
  1225. {confirmDeleteProject && (
  1226. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1227. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1228. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1229. <div className="flex items-center gap-3 mb-4">
  1230. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1231. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1232. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1233. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1234. </svg>
  1235. </div>
  1236. <div>
  1237. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>
  1238. Delete "{project?.name}"?
  1239. </h3>
  1240. <p className="text-xs mt-0.5" style={{ color: '#F87171' }}>
  1241. {assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted
  1242. </p>
  1243. </div>
  1244. </div>
  1245. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1246. This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.
  1247. </p>
  1248. <div className="flex gap-3 justify-end">
  1249. <button
  1250. onClick={() => setConfirmDeleteProject(false)}
  1251. disabled={deletingProject}
  1252. className="btn btn-secondary btn-md"
  1253. >
  1254. Cancel
  1255. </button>
  1256. <button
  1257. onClick={handleDeleteProject}
  1258. disabled={deletingProject}
  1259. className="btn btn-danger btn-md"
  1260. >
  1261. {deletingProject ? 'Deleting…' : 'Delete Project'}
  1262. </button>
  1263. </div>
  1264. </div>
  1265. </div>
  1266. )}
  1267. </div>
  1268. );
  1269. }